#!/usr/bin/env python
#Copyright 2011 Jesse Hattabaugh <arkanciscan@gmail.com>
#Licensed under the Artistic License 2.0 http://www.opensource.org/licenses/artistic-license-2.0

import os,urllib,re,logging,random,cgi
from collections import defaultdict
from google.appengine.ext import webapp,db,blobstore
from google.appengine.ext.webapp import WSGIApplication,RequestHandler,util
from google.appengine.ext.webapp.mail_handlers import InboundMailHandler
from google.appengine.api import users,memcache,mail
from templates.filters import normalize,denormalize,strip_email,nickname
from mako.template import Template
from mako.lookup import TemplateLookup
from mako.exceptions import html_error_template

# Models

class Settings(db.Model):
	created=db.DateTimeProperty(auto_now_add=True)
	user=db.UserProperty()
	nickname=db.StringProperty()
	email=db.EmailProperty()
	question_emails=db.BooleanProperty(default=True)
	
	@staticmethod
	def get(user):
		s=memcache.get('settings/'+user.user_id())
		if not s:
			s=Settings.get_or_insert(user.user_id(),user=user)
			logging.info('loaded settings for '+user.email())#
			memcache.set('settings/'+user.user_id(),s)
		return s
		
	def put(self):
		memcache.set('settings/'+self.key().name(),self)
		db.Model.put(self)
		
	def get_email(self):
		if self.email:
			return self.email
		else:
			return self.user.email()

class Thing(db.Model):
	created=db.DateTimeProperty(auto_now_add=True)
	user=db.UserProperty(required=True,auto_current_user_add=True)
	type=db.StringProperty(required=True,choices=set(['have', 'want']))
	answered=db.BooleanProperty(required=True,default=False)
	questions=db.StringListProperty()
	answers=db.StringListProperty()
	declined=db.StringListProperty() #question keys the owner has declined
	photo=blobstore.BlobReferenceProperty() #todo multiple photos
	
	def facts(self):
		facts=memcache.get('facts/%s'%self.key())
		if not facts:
			#todo: warn users if they excede 100 facts
			facts=Fact.all().filter('thing',self).filter('declined !=',True).fetch(100)
		return facts
	
	def suggestions(self):
		suggestions=memcache.get('suggestions/%s'%self.key())
		if not suggestions:
			q_counts=defaultdict(int)
			a_counts=defaultdict(dict)
			for q in self.questions:
				q_facts=Fact.all().filter('questions', q)
				for f in q_facts:
					t_facts=Fact.all().filter('thing',f.thing)
					for o in t_facts:
						for q_key in o.questions:
							if q_key not in self.questions and q_key not in self.declined:
								if not o.declined:
									q_counts[q_key]+=1
									for a_key in o.answers:
										if not a_counts[q_key]:
											a_counts[q_key]=defaultdict(int)
										a_counts[q_key][a_key]+=1
								else:
									q_counts[q_key]-=1
			suggestions={'questions':{},'answers':{}}				
			suggestions['questions']=sorted(q_counts,key=q_counts.get,reverse=True)
			for q_key in a_counts:
				a=a_counts[q_key]
				suggestions['answers'][q_key]=sorted(a,key=a.get,reverse=True)
			memcache.set('suggestions/%s'%self.key(),suggestions,60)
		return suggestions
	
	def suggested_questions(self):
		suggestions=self.suggestions()
		return suggestions['questions']
	
	def suggested_answers(self):
		suggestions=self.suggestions()
		return suggestions['answers']
	
	def compare(self):
		#todo: prebuild in TaskQueue?
		compare=memcache.get('compare/%s'%self.key())
		if not compare:
			similar_lists=[]
			matches_lists=[]
			for f in self.facts():
				if f.answers:
					#todo support multiple questions and answers in facts
					#todo order by newest facts first
					similar_lists.append(Thing.all(keys_only=True).filter('type',self.type).filter('questions',f.questions[0]).filter('answers',f.answers[0]).fetch(1000))
					matches_lists.append(Thing.all(keys_only=True).filter('type',('have' if self.type=='want' else 'want')).filter('questions',f.questions[0]).filter('answers',f.answers[0]).fetch(1000))
					#todo: filter('user' != self.user)
			similar_tally=tally(similar_lists)
			matches_tally=tally(matches_lists)
			compare={'similar':{},'matches':{}}
			compare['similar']=Thing.get(sorted(similar_tally,key=similar_tally.get,reverse=True))
			compare['matches']=Thing.get(sorted(matches_tally,key=matches_tally.get,reverse=True))
			memcache.set('compare/%s'%self.key(),compare)
		return compare
	
	def similar(self):
		compare=self.compare()
		return compare['similar']
	
	def matches(self):
		compare=self.compare()
		return compare['matches']
	
	def upload_url(self):
		return blobstore.create_upload_url('/%s/%s'%(self.type,self.key().id()))
	
	def clear_cache(self):
		memcache.delete('similar/%s'%self.key())
		memcache.delete('matches/%s'%self.key())
		memcache.delete('suggestions/%s'%self.key())

class Question(db.Model):
	count=db.IntegerProperty(required=True,default=0)
	declines=db.IntegerProperty(required=True,default=0)

class Answer(db.Model):
	count=db.IntegerProperty(required=True,default=0)

class Fact(db.Model):
	created=db.DateTimeProperty(auto_now_add=True)
	thing=db.ReferenceProperty(required=True)
	askers=db.ListProperty(users.User)
	answered=db.BooleanProperty(required=True,default=False)
	declined=db.BooleanProperty(default=False)
	questions=db.StringListProperty(required=True)
	answers=db.StringListProperty()
	order=db.IntegerProperty(required=True,default=0)

# Exceptions

class Forbidden(Exception):
	def __init__(self,msg='Forbidden'):
		if users.get_current_user():
			self.code=403
		else:
			self.code=401
		self.msg=msg

class NotFound(Exception):
	def __init__(self,msg='Not Found'):
		self.code=404
		self.msg=msg

class BadRequest(Exception):
	def __init__(self,msg='Bad Request'):
		self.code=400
		self.msg=msg

# Request Handlers

class AppHandler(RequestHandler):
	def handle_exception(self,ex,debug=True):
		logging.exception(ex)
		if hasattr(ex,'code'):
			self.error(int(ex.code))
			out={'msg':ex.msg}
			if int(ex.code)==401:
				out['login_url']=users.create_login_url(self.request.uri)
				self.render('login',out)
			else:
				self.render('error',out)
		else:
			self.error(500)
			if debug:
				self.response.out.write(html_error_template().render())
			else:
				self.render('500')
	
	def render(self,view,out={}):
		u=users.get_current_user()
		if u:
			s=Settings.get(u)
			out['user_nickname']=nickname(u.user_id())
			out['user_id']=u.user_id()
			out['user_email']=u.email()
			out['user_action']='logout'
			out['user_link']=users.create_logout_url('/')
		else:
			out['user_action']='login'
			out['user_link']=users.create_login_url(self.request.uri)
		if 'debug' in out:
			from pprint import pformat
			out['debug']=pformat(out['debug'])	
		file=view+'.html'
		lookup=TemplateLookup(['templates'])
		template=lookup.get_template(file)
		self.response.out.write(template.render(**out))
	
	def render_mail(self,message,view,out={}):
		lookup=TemplateLookup(['templates'])
		body_template=lookup.get_template(view+'.txt')
		message.body=body_template.render(**out)
		#todo use an html template if one exists
		message.html='<br />'.join(body_template.render(**out).splitlines())
		message.send()

class MainHandler(AppHandler):
	def get(self,page=None):
		out={}
		if self.request.get('migrate') and users.is_current_user_admin():
			from migrations import initialize_fact_declined
			out['debug']=initialize_fact_declined()
		if page:
			self.render(page,out)
		else:
			self.render('main',out)

class SettingsHandler(AppHandler):
	def get(self):
		u=users.get_current_user()
		if u:
			s=Settings.get(u)
			self.render('settings',{'settings':s})
		else:
			raise Forbidden()
	
	def post(self):
		u=users.get_current_user()
		if u:
			s=Settings.get(u)
			if s:
				if self.request.get('nickname'):
					s.nickname=cgi.escape(self.request.get('nickname'))
				else:
					s.nickname=None
				if self.request.get('email'):
					s.email=self.request.get('email')
				else:
					s.email=None
				s.question_emails=bool(self.request.get('question_emails'))
				s.put()
				logging.info('saving settings for %s'%u.email())
			self.redirect('/')
		else:
			raise Forbidden()

class ThingHandler(AppHandler):
	def redirect_to_new(self,type):
		t=Thing.all().filter('user',users.get_current_user()).filter('type',type).filter('answered',False).get()
		if not t:
			t=Thing(type=type)
			t.put()
		self.redirect('/%s/%s/fact'%(type,t.key().id()))
		
	def get(self,type=None,id=None,format=None):
		#todo move method redirecting to AppHandler before filter or a decorator
		if self.request.get('method_post'):
			return self.post(type,id,format)
		elif self.request.get('method_delete'):
			return self.delete(id)
			
		out={'type':type}
		if id=='you' or not id: #list
			query=Thing.all().order('-created').filter('answered', True)
			if type:
				query.filter('type', type)
			if id=='you':
				u=users.get_current_user()
				if u:
					out['other_user_id']=u.user_id()
					query.filter('user =', u)
				else:
					raise Forbidden()
			elif self.request.get('user_id'):
				s=db.Model.get(db.Key.from_path('Settings',self.request.get('user_id')))
				if s:
					out['other_user_id']=s.user.user_id()
					query.filter('user =', s.user)
				else:
					raise NotFound()
			things=query.fetch(100)
			if not things and id=='you':
				self.redirect_to_new(type)
			out['things']=things
			self.render('things',out)
			
		else: #details
			t = Thing.get_by_id(int(id))
			if t:
				if format=='jpeg':
					#todo: consider moving this to an ImageHandler
					self.response.headers[blobstore.BLOB_KEY_HEADER]=str(t.photo.key())
					del self.response.headers['Content-Type']
					self.response.clear()
				else:
					out['thing']=t
					out['is_owner']=users.get_current_user()==t.user
					if users.get_current_user()==t.user:
						out['things']=t.matches()
					else:
						out['things']=t.similar()
					self.render('thing', out)
			else:
				raise NotFound()
	
	def post(self,type=None,id=None,format=None):
		if id: #todo move method redirecting to AppHandler before filter or a decorator
			return self.put(id)
		if users.get_current_user():
			self.redirect_to_new(type)
		else:
			raise Forbidden()
	
	def put(self,id):
		field=self.request.params['photo']
		if isinstance(field,cgi.FieldStorage):
			if 'blob-key' in field.type_options:
				blob=blobstore.parse_blob_info(field)
		t = Thing.get_by_id(int(id))
		#todo what if we're putting changes to a Thing's description?
		t.photo=blob.key() #todo what if blob.key() doesn't exist
		t.put()
		logging.info('put key "%s" in Thing "%s"'%(str(blob.key()),id))
		self.redirect('/%s/%s'%(t.type,id))
	
	def delete(self,id):
		t=Thing.get_by_id(int(id))
		if t:
			if t.user==users.get_current_user():
				#todo move following to Thing.delete()
				fs=Fact.all().filter('thing',t)
				for f in fs:
					#todo move the following to Fact.delete()
					for q_key in f.questions:
						#todo Question.delete or Fact.delete_question
						q=Question.get_by_key_name(q_key)
						if f.declined:
							q.declines-=1
						else:
							q.count-=1
							#todo? if q.count==0: q.delete()
						q.put()
						
					for a_key in f.answers:
						#todo Answer.delete or Fact.delete_answer
						a=Answer.get_by_key_name(a_key)
						a.count-=1
						#todo? if a.count==0: a.delete()
						a.put()
					f.delete()
					logging.info('deleted a Fact "q_key"')
				#todo delete photos
				t.delete()
				logging.info('deleted Thing')
				self.redirect('/%s'%(t.type),True)
			else:
				raise Forbidden()
		else:
			raise NotFound()

class FactHandler(AppHandler):
	def get(self,type,thing_id=None,fact_id=None):
		"""
		displays an existing Fact, found by id or Question key. If no 
		Fact exists a dummy is created just to persist the values in the 
		form. 
		"""
		#todo move method redirecting to AppHandler before filter or a decorator
		if self.request.get('method_delete'):
			return self.delete(thing_id,fact_id)
			
		t=Thing.get_by_id(int(thing_id))
		if t:
			suggestions=t.suggestions()
			out={'type':t.type,
				'thing':t,
				'is_owner':users.get_current_user()==t.user,
				'suggested_questions':suggestions['questions']}
			q_key=self.request.get('question') #todo multiple questions
			a_key=self.request.get('answer') #todo multiple answers
			f=None
			if fact_id: #find by id
				f=Fact.get_by_id(int(fact_id))
				if not f:
					#todo? raise NotFound()
					logging.warning('invalid Fact id "%s"'%fact_id)
					self.redirect('/%s/%s'%(t.type,thing_id))
			elif q_key: #find by question
				f=Fact.all().filter('thing',t).filter('questions',q_key).get()
				if not f: #create a dummy
					f=Fact(thing=t,questions=[q_key])
			if f:
				#f is not put() in this method so it's not necessary to increment Question/Answer.count 
				if q_key and q_key not in f.questions:
					f.questions.append(q_key)
				if a_key and a_key not in f.answers:
					f.answers.append(a_key)
				out['fact']=f
				if suggestions['answers'].get(f.questions[0]):
					out['suggested_answers']=suggestions['answers'].get(f.questions[0])[:10]
			self.render('fact',out)
		else:
			raise NotFound()
	
	def post(self,type,thing_id=None,fact_id=None):
		#todo move method redirecting to AppHandler before filter or a decorator
		if self.request.get('method_delete'):
			return self.delete(thing_id,fact_id)
		if fact_id or self.request.get('method_put'):
			return self.put(type,thing_id,fact_id)
		
		t=Thing.get_by_id(int(thing_id))
		redirect_url='/'+type
		if t:
			redirect_url+='/'+thing_id+'/fact'
			u=users.get_current_user()
			if u:
				q_key=normalize(self.request.get('question')) #todo multiple questions
				a_key=normalize(self.request.get('answer')) #todo multiple answers
				if q_key:
					#todo move the following to Fact.put()
					if not q_key in t.questions:
						t.questions.append(q_key) 
					f=Fact.all().filter('thing',t).filter('questions',q_key).get()
					if not f:
						f=Fact(thing=t,questions=[q_key])
						q=Question.get_or_insert(q_key)
						q.count+=1
						q.put()
					if u==t.user:
						f.declined=False #can this go somewhere else?
						
						if a_key and not a_key in f.answers:
							f.answers=[a_key]
							f.answered=True
							a=Answer.get_or_insert(a_key)
							a.count+=1
							a.put()
							
							t.clear_cache()
							
						if a_key and not a_key in t.answers:
							t.answers.append(a_key)
							t.answered=True
						t.put()
					else:
						if not u in f.askers:
							f.askers.append(u)
							self.ask_email(f)
					f.put()
					logging.info('saving Fact')
					self.redirect(redirect_url)
				else:
					raise BadRequest()
			else:
				raise Forbidden()
		else:
			raise NotFound()
	
	def ask_email(self,fact):
		u=users.get_current_user()
		s=Settings.get(fact.thing.user)
		if s.question_emails:
			message=mail.EmailMessage(
				sender='fact@haveorwant.appspotmail.com',
				to=s.get_email(),
				subject='%s?'%denormalize(fact.questions[0]))
			self.render_mail(message,'question_asked',{
				'asker':u,
				'thing':fact.thing,
				'question':fact.questions[0]})
			logging.info('sent email to %s on behalf of %s'%(s.user.email(),u.email()))
	
	def put(self,type,thing_id,fact_id):
		u=users.get_current_user()
		if u:
			f=Fact.get_by_id(int(fact_id))
			if f:
				#todo move the following into Fact.put()
				if u==f.thing.user:
					if f.answers:
						old_a_key=f.answers[0]#todo multiple answers
						old_a=Answer.get_by_key_name(old_a_key)
						old_a.count-=1
						old_a.put()
						f.thing.answers.remove(old_a_key)
					
					#todo what if user doesn't provide an answer?
					new_a_key=normalize(self.request.get('answer'))
					new_a=Answer.get_or_insert(new_a_key)
					new_a.count+=1
					new_a.put()
					f.thing.answers.append(new_a_key)
					f.answers=[new_a_key]#todo multiple answers
					f.answered=True
					
					f.thing.put()
				elif u not in f.askers:
					#asking a question someone else has already asked
					f.askers.append(u)
					self.ask_email(f)
				f.put()
				
				f.thing.clear_cache()
				self.redirect('/%s/%s'%(type,thing_id))
				
			else:
				raise NotFound()
		else:
			raise Forbidden()
	
	def delete(self,thing_id,fact_id=None):
		#todo this logic is a little complicated because I am handling question 
		#declining as well as deleting existing facts. Might separate later.
		t=Thing.get_by_id(int(thing_id))
		if t:
			back_url='/%s/%s'%(t.type,t.key().id())
			if self.request.get('back')!='thing':
				back_url=back_url+'/fact'
			if t.user==users.get_current_user():
				#determine the question and maybe the fact
				q_key=normalize(self.request.get('question'))
				
				#Move the following to Thing.delete_fact()
				if q_key:#declining a question
					if q_key:
						q=Question.get_by_key_name(q_key)
						f=Fact.all().filter('thing',t).filter('questions',q_key).get()#might return nothing if declining
					else:
						logging.warning('invalid question "%s"'%self.request.get('question'))
						self.redirect(back_url)
				elif fact_id:#deleting an existing fact
					f=Fact.get_by_id(int(fact_id))
					if f:
						q_key=f.questions[0]#todo handle multiple questions
						q=Question.get_by_key_name(q_key)
					else:
						logging.warning('no Fact with id "%s"'%fact_id)
						self.redirect(back_url)
				else:
					logging.warning('not enough info to find fact')
					self.redirect(back_url)
				
				if not f:#q must be true
					f=Fact(thing=t,questions=[q_key])
					logging.info('created a fact to decline "%s"'%q_key)
				f.declined=True
				f.put()
				logging.info('set fact to declined')
				
				for a_key in f.answers:
					a=Answer.get_by_key_name(a_key)
					if a_key in t.answers:
						t.answers.remove(a_key)
						a.count-=1
						logging.info('decreased the count of answer "%s"'%a_key)
					a.put()
						
				if q_key in t.questions:
					t.questions.remove(q_key)
					q.count-=1
					logging.info('decreased the count of question')
					
				if q_key not in t.declined:
					t.declined.append(q_key)
					q.declines+=1
					logging.info('increased the declines of question')
				
				q.put()
				t.put()
				t.clear_cache()
			else:
				raise Forbidden()
		else:
			raise NotFound()

class MailHandler(AppHandler):
	
	def post(self,address):
		parts=strip_email(urllib.unquote(address)).split('/')
		method=parts[0]
		if len(parts)>1:
			id=parts[1]
		else:
			id=None
		message=mail.InboundEmailMessage(self.request.body)
		logging.info('email from '+message.sender)
		if method in['have','want']:
			self.thing(message,method)
		elif method in ['fact']:
			if id:
				getattr(self, method)(message)
			else:
				getattr(self, method)(message,id)
		else: # forward catch-all
			sender='forward@haveorwant.appspotmail.com'
			to='arkanciscan@gmail.com'
			original=message.original,
			forward=mail.EmailMessage(
				sender=sender,
				to=to,
				subject='Fwd: '+message.subject
			)
			forward.send()
	
	def thing(self,message,type):
		from email.utils import parseaddr
		from pprint import pformat
		logging.info(pformat(parseaddr(message.sender)))
		email=parseaddr(message.sender)[1]
		s=Settings.all().filter('email',email).get()
		if s:
			u=s.user
		else:
			s=Settings.all().filter('user',users.User(email)).get()
			if s:
				u=s.user
		if u.user_id():
			
			t=Thing(type=type,user=u)
			
			#todo move this to Thing.add_question()
			q=Question.get_or_insert('what_is_it')
			t.questions=[q.key().name()]
			q.count+=1
			q.put()
			
			#todo move this to Thing.add_answer()
			a_key=normalize(message.subject)
			if a_key:
				t.answered=True
				a=Answer.get_or_insert(a_key)
				t.answers=[a.key().name()]
				a.count+=1
				a.put()
			
			t.put()
			
			f=Fact(thing=t,questions=[q.key().name()],answers=[a.key().name()])
			f.put()
			
			if hasattr(message, 'attachments'):
				
				import urllib2, urllib
				from cStringIO import StringIO
				from urllib2_file import UploadFile
				
				photo=UploadFile(StringIO(message.attachments[0][1].decode()),message.attachments[0][0])
				urllib2.urlopen(t.upload_url(),{'photo':photo})
				
				logging.info('did it work?')
				
				if len(message.attachments)>1:
					logging.warning('don\'t know what to do with multiple attachments')
			else:
				logging.info('no attachments to process')
		else:
			logging.warning('unrecognized email address: '+email)
		
	def fact(self,message,id=None):
		answers=[]
		for type, body in message.bodies():
			answers.append(body.decode().splitlines()[0])
		from pprint import pformat
		logging.info('recieved the following answers '+pformat(answers))

def tally(lists):
	out=defaultdict(int)
	for list in lists:
		for item in list:
			out[item]+=1
	return out
	
application=WSGIApplication([
	('/',MainHandler),
	(r'/(have|want)/([^/]*)/fact/?([^/]*)',FactHandler),
	(r'/(have|want)/?([^\./]*)[\./]?(html|jpeg)?',ThingHandler),
	('/settings',SettingsHandler),
	(r'/_ah/mail/(.+)',MailHandler),
	(r'/(privacy|terms|credits)',MainHandler)
],debug=True)

def main():
	util.run_wsgi_app(application)

if __name__ == '__main__':
	main()
	
